Ruby net::http库默认重试一次请求问题

  1. 问题描述:并发20个线程,每个线程使用一个单独的BosClient去执行5G文件的PUT/GET操作,并比较下载文件和原文件的MD5,发现偶尔出现文件MD5对不上,文件大小大于5G的情况的情况,出现几率大概1%;
  2. 问题定位
    • 查看sdk日志和BOS日志,发现该情况下client发送了2次GET请求,第一次返回200但是err msg是PartialContentError
    • 进一步查看nginx error log发现是客户端主动关闭了连接,可能是因为TCP连接超时等原因导致client主动关闭连接
    • 但是从client日只看并没有触发重试机制,那么多出来的GET请求可能就是sdk用到的http库主动发起的
  3. 问题原因
    • 定位发现sdk引用了第三方库rest-client,rest-client又引用了ruby语言自带的net::http库来发起http请求
    • 查看net::http库源码发现,默认自带了一次重试机制;重试时不会对重置读取到的body stream,而是会继续追加写,导致文件大小大于5G
    • Net::HTTP read_timeout causes double requests
  4. 解决方案
    net::http库默认重试一次的机制不合理,因此有用户提出了bug,建议retry次数可配置Net::HTTP retries idempotent requests once after a timeout, but its not configurable;AWS ruby sdk也发现了这一问题,提交了PR,不过要到ruby 2.5版本才支持直接设置重试次数。考虑到版本向下兼容问题,我们采取monkey patching的方式来解决这一问题,两个思路:

    • 方案一:第二次重试的时候将body stream重置到起始位置

      module Net
      class HTTP
      def transport_request(req)
            count = 0
            begin
              begin_transport req
              res = catch(:response) {
                req.exec @socket, @curr_http_version, edit_path(req.path)
                begin
                  res = HTTPResponse.read_new(@socket)
                  res.decode_content = req.decode_content
                end while res.kind_of?(HTTPContinue)
                res.uri = req.uri
                res
              }
              res.reading_body(@socket, req.response_body_permitted?) {
                yield res if block_given?
              }
            rescue Net::OpenTimeout
              raise
            rescue Net::ReadTimeout, IOError, EOFError,
                   Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
                   # avoid a dependency on OpenSSL
                   defined?(OpenSSL::SSL) ? OpenSSL::SSL::SSLError : IOError,
                   Timeout::Error => exception
              if count == 0 && IDEMPOTENT_METHODS_.include?(req.method)
                count += 1
                @socket.close if @socket and not @socket.closed?
                D "Conn close because of error #{exception}, and retry"
                // 添加重置body_stream操作
                if req.body_stream
                  if req.body_stream.respond_to?(:rewind)
                    req.body_stream.rewind
                  else
                    raise
                  end
                end
                retry
              end
              D "Conn close because of error #{exception}"
              @socket.close if @socket and not @socket.closed?
              raise
            end
            end_transport req, res
            res
          rescue => exception
            D "Conn close because of error #{exception}"
            @socket.close if @socket and not @socket.closed?
            raise exception
          end
      end
      end
      
    • 方案二:去除重试机制,删掉retry语句
  5. 参考链接